第 11 天結尾,具名引言和待辦負責人停在 needs-human。
停是停了。但我回頭看那個標籤,它其實什麼都沒交代:誰來看?看完把答案寫在哪?下次程序啟動時,怎麼知道有人回過、回的是哪一版?
needs-human 如果只是一個字串,人工交接就還藏在工作流外面。今天補的就是這一段:第一個程序把問題存下來、關掉;第二個程序讀回答案,只把停住的那條分支跑完。匿名報告不用陪著等。
第 7 天到第 11 天,其實一直在補同一條鏈:
| 天數 | 新增能力 | 留給下一天的產物 |
|---|---|---|
| 7 | 分清一次任務、固定工作流與自動化 | 自動化契約 |
| 8 | 輸入不合格時安全停止 | 輸入契約 |
| 9 | 每一站都能交付、拒收與回查 | 交接紀錄 |
| 10 | 整理後文字回到來源時間窗 | 可回放逐字稿包 |
| 11 | 分開匿名分群、身分綁定與下游權限 | 身分候選與受限輸出 |
| 12 | 只暫停具名分支並跨程序續跑 | 複核請求、決定與分支狀態 |
前面五天畫的都是狀態和契約。今天是第一次階段驗收:不只畫,要真的讓第一次程序停下,第二次程序接著跑。
跟第 11 天的分工也要說清楚。第 11 天用負向測試證明匿名標記不能直接變姓名,也建立了摘要與過期判定。今天沿用這些結論,不重講;今天處理的是第 11 天沒碰的兩題:未確認時怎麼等,人回覆後怎麼繼續。
全文只回答一個問題:
身分需要人判斷時,工作流怎麼讓匿名報告先完成,只暫停具名分支,等另一個程序讀回同一來源版本的決定再續跑?
不比較人工介入框架,不做完整會議工具。今天交付的是一個最小、可執行、也可以失敗的分支狀態機。下面講。
用生活的方式想:外場把點單釘在廚房窗口,就去忙別桌,不會站在窗口等。廚師看完在單子上寫答案,釘回去。下一個來拿單的外場可能已經換班,他不需要知道前一個人在想什麼,照單子做就好。
這次的合成範例就是這樣跑的:
第一個 Node.js 程序執行 start,得到 waiting_for_human。它把四份檔案逐檔原子替換寫進暫存目錄:current-source.json、anonymous-report.json、review-request.json、state.json,加上一份執行軌跡。然後結束。
測試再啟動第二個 Node.js 程序執行 resume。它讀回 current-source.json、review-decision.json、state.json,得到 completed_named_metadata。兩次程序識別碼不同,測試每次都會另啟兩個程序並斷言兩者不一樣。
這比在同一個函式裡「等一個立刻回傳的模擬答案」多驗了一層:記憶體已經消失,續跑只能靠保存下來的狀態和決定。
它沒驗的也要先講:單一寫入者、作業系統暫存目錄、沒有並行、沒有程序崩潰時的跨檔交易、沒有遠端佇列。只證明兩個不同程序能用 JSON 檔接續。
老實說,最容易的寫法是一個 approved: true。它壞在後面:核准的是什麼?適用哪一版?重送過沒有?解鎖了哪一項能力?一個布林值答不了這四題。
所以拆成三份:
| 資料 | 誰建立 | 必須保存什麼 | 不能取代什麼 |
|---|---|---|---|
review-request.json |
工作流 | 範圍、分支、來源版本、候選、允許決定、能力上限 | 人的實際決定 |
review-decision.json |
複核端 | 唯一決定識別碼、請求版本、來源綁定、選項 | 登入、授權與內容真實性 |
state.json |
工作流 | 各分支狀態、待處理請求、已處理決定、事件序列 | 正式資料庫的交易與鎖 |
三份都是本文的最小資料形狀,不是任何框架或雲端服務的官方格式。
{
"review_request_id": "review-run-synthetic-day12-001-v1",
"request_version": 1,
"run_id": "run-synthetic-day12-001",
"review_scope": "speaker_identity",
"branch_id": "named_output",
"source_artifact": {
"artifact_id": "transcript-package-synthetic-day12",
"version": "sha256:f7cb93a5…b3f2"
},
"allowed_decisions": [
"confirm_identity",
"keep_anonymous",
"reject_candidate"
],
"capability_ceiling": ["write:named_speaker_metadata"]
}
請求先界定:只能判斷 speaker_identity,只作用於 named_output,來源是哪一版摘要,人只能在三個選項裡選。capability_ceiling 是天花板:就算複核端回傳更多權限,工作流也不能超過它。
所有識別碼、人物參照和摘要都來自明示合成的固定資料,沒有真實姓名、音訊或逐字稿。
{
"decision_id": "decision-fixture-confirm-v1",
"review_request_id": "review-run-synthetic-day12-001-v1",
"request_version": 1,
"review_scope": "speaker_identity",
"branch_id": "named_output",
"source_artifact": {
"artifact_id": "transcript-package-synthetic-day12",
"version": "sha256:f7cb93a5…b3f2"
},
"choice": "confirm_identity",
"confirmed_candidate_id": "candidate-speaker-00-person-alpha",
"reviewer_id": "reviewer_synthetic_fixture"
}
續跑前逐一比對:請求識別碼、請求版本、複核範圍、分支、來源摘要、候選。少一項對不上,就不續跑。決定資料也不能自己夾帶 publish:content 這種新能力。
reviewer_id 在這裡只是合成的稽核欄位。登入、權限、真人身分,都不在這次測試範圍。
分支怎麼切,是今天最重要的設計決定。
| 分支 | 第一次程序結束前 | 人工決定後 |
|---|---|---|
anonymous_report |
completed,已保存 anonymous-report.json |
保持完成,不因具名決定而重算 |
named_output |
waiting_for_human |
依決定成為 completed_named_metadata、completed_anonymous_only 或 stopped_rejected |
共同前處理成功後,匿名報告就有自己的完成條件。身分不確定只擋需要真實身分的那條,不能反過來抹掉已經完成的匿名輸出。
這裡有個容易寫過頭的地方。我查了 OpenAI Agents SDK 的人工介入介面,它是整個執行範圍的:需要核准的工具呼叫變成中斷項目,呼叫端拿到狀態後核准或拒絕,再繼續整個執行。這不能被寫成「框架天然只暫停具名分支」。
後來發現正確的分工是:框架負責保存與恢復的機制;哪個分支可以先完成,是我們在框架外面先切工作單元才得到的。匿名和具名要先設計成獨立單元,暫停才會只停一邊。
| 決定 | 具名分支結果 | 新增能力 | 明確禁止 |
|---|---|---|---|
confirm_identity |
completed_named_metadata |
write:named_speaker_metadata |
具名引言、建立待辦、發布 |
keep_anonymous |
completed_anonymous_only |
write:anonymous_speaker_metadata |
具名中繼資料與所有外部動作 |
reject_candidate |
stopped_rejected |
無 | 不得改用另一個名字猜測 |
三條路都保留已完成的匿名報告。
confirm_identity 只產生說話者標記與人物參照的中繼資料,不含顯示名稱、引言或待辦。這是最小權限的直接套用:NIST 對最小權限的描述是,只授予使用者或程序完成被指派工作所需的最低權限。speaker_identity 這個決定被指派的工作,只有寫具名說話者中繼資料。具名引言還要文字忠實與公開授權;建立待辦要另一份行動批准;發布是更後面的獨立權限。
reject_candidate 那一列的禁止項要特別看:拒絕候選之後,程式不能自己換一個名字再猜。拒絕就是拒絕。
一條人工交接會不會壞,不是看它順跑一次,是看它被重送、換版、改內容時會不會做錯事。
| 變異 | 實跑結果 | 狀態是否被錯改 |
|---|---|---|
相同 decision_id、相同內容重送 |
duplicate_noop |
否,序列化狀態完全不變 |
| 來源已換版才送回舊決定 | stale_reissued |
舊請求過期,新版重新派件 |
相同 decision_id 改成另一個選項 |
decision_id_reuse_conflict |
否,拒絕套用 |
| 複核範圍或分支不符 | review_scope_mismatch/branch_id_mismatch |
否 |
| 確認了另一個候選 | candidate_mismatch |
否 |
| 決定資料夾帶額外能力 | decision_capability_injection |
否 |
另外還驗了兩件事:已結案的請求不能用新的 decision_id 重開;狀態轉移不修改輸入物件。這批自動測試我這次跑是 14 條全過。
原因碼只屬於本篇範例,不是框架或標準規定的共同錯誤碼。
每一列背後都有一個外部世界的理由,逐條講:
▸ 重送要能安全忽略。 外部事件常常是至少送達一次。Azure Durable Task 的文件就明說重複事件可能出現,要去重就在事件資料裡放唯一識別碼。這正是 decision_id 的工作。
▸ 但冪等不是看到識別碼重複就吞掉。 AWS Builders' Library 特別處理同一個識別碼被拿來表達不同意圖的風險。所以這裡同時保存 decision_id 和決定內容摘要:相同識別碼、相同內容,回同一個結果;相同識別碼、內容不同,明確衝突,不猜哪一份算數。
▸ 來源換版,舊決定要失效。 AWS Step Functions 的回呼模式把任務權杖交給外部工作者,逾時之後會產生新權杖,舊的不再有效。HTTP 的 If-Match 也是同一個原則:目前版本和指定版本不符就不套用變更。本文採一樣的失敗關閉:決定綁的來源摘要和目前來源不同,舊請求標成 stale,為新版重新派一件 v2,具名分支繼續等。
▸ 範圍、分支、候選任一不符,都不續跑。 這三項在請求裡已經寫死,決定回來對不上,就是另一件事的答案被送錯地方。
▸ 能力只能收窄,不能被決定放大。 決定資料夾帶 publish:content,工作流照 capability_ceiling 擋掉。
重播不是「再跑一次看看」。它是:對同一個意圖得出同一個結果,對不同意圖明確衝突。
我沒有用任何一個框架實作這個範例,但它們的文件把責任邊界講得很清楚,值得對照。
Microsoft Agent Framework 用明確的請求與回覆處理人工介入:執行器送出請求,工作流暫停,呼叫端提供回覆後再繼續。文件也說,若建立檢查點,待處理請求會隨狀態保存,恢復時重新發出。這支持「人工交接做成資料,不是口頭訊息」。
同一套文件區分兩種儲存:記憶體儲存適合同一程序內恢復;檔案式儲存才能在程序重啟後讀回檢查點,而且恢復時工作流拓樸與執行器識別要相容。所以本文不用「標成等待人工」推論持久化已完成,而是讓第一個程序真的寫檔、結束,第二個程序讀檔驗證。
OpenAI Agents SDK 的 RunState 提供把執行狀態轉成字串、再從字串恢復的介面,長時間等待不必維持同一個記憶體程序。但可以序列化,不等於已經安全保存。放哪裡、誰能讀、版本相不相容、恢復哪個業務分支,都還是應用程式的責任。
三份文件的共同點:框架處理「停下來、存起來、接回去」。至於停哪一條、存什麼欄位、接回去時要驗哪些對應,是我們的事。
來源包、複核請求、複核決定、具名中繼資料,在本文裡是四個不同實體;建立請求和套用決定是兩個不同活動。W3C PROV-DM 用實體、活動、代理者描述資料怎麼產生、被誰用、歸誰。分開記之後,才回答得了「哪一版來源被哪個決定改變了哪個分支」,而不是只看到最後一份輸出。
摘要的部分沿用第 11 天:先遞迴排序 JSON 物件鍵,再算 SHA-256,避免只是鍵順序不同就被判成來源換版。這個簡化函式只服務固定合成資料,不是完整的 RFC 8785 實作。
SHA-256 能辨認位元有沒有變。摘要不同,資料變了;摘要相同,只說明輸入表示相同。它不能證明候選姓名正確、複核者是真人,或這個決定已獲組織授權。
證明了:
1/ 匿名分支可以在第一個程序裡完成,不用陪具名分支等人。
2/ 第一個程序結束後,第二個程序只靠檔案就能續跑具名分支。
3/ 三種決定各自只改具名分支,能力不超過請求寫死的天花板。
4/ 六種重播變異都不會錯改狀態。
沒證明的:
▸ 多寫入者、並行、程序崩潰時的跨檔交易。這是本機單一寫入者範例,不是正式交易系統。
▸ 真實複核者介面、登入、授權。reviewer_id 只是合成欄位。
▸ 任何語音模型準確率或真實會議證據。第 11 天和今天都只用合成資料驗控制層。
今天只把 speaker_identity 做成能暫停與續跑的分支。
Day 13 要新增的是行動候選:逐字稿裡有人提到一件事、會議形成決定、某人承諾負責、必要欄位仍缺,這四種要成為不同狀態。
這道權限不能偷渡。身分確認只允許寫具名說話者中繼資料;行動候選要保留自己的來源片段與版本,Day 14 再用另一份批准,決定能不能交給假待辦執行器。